Skip to content

Comments

fix: 서버 클라이언트 hydration 불일치 오류, 시간문제 해결#376

Merged
Hyeonjun0527 merged 1 commit intodevelopfrom
fix/one-to-one-new-feature-bug
Feb 4, 2026
Merged

fix: 서버 클라이언트 hydration 불일치 오류, 시간문제 해결#376
Hyeonjun0527 merged 1 commit intodevelopfrom
fix/one-to-one-new-feature-bug

Conversation

@Hyeonjun0527
Copy link
Member

@Hyeonjun0527 Hyeonjun0527 commented Feb 4, 2026

🌱 연관된 이슈

☘️ 작업 내용

아카이브 ui 보완
밸런스 게임 > url 공유 기능 (투표 글 하나에 대해서 외부로 공유 가능하게끔)
밸런스게임에서 프사가 안보임 해결
나의 스터디목록 ui에 답변자 -> 지원자 이렇게 문구 바꿈.
밸런스게임에 태그 필터 기능 추가.
밸런스게임 글 작성에 기존 태그 목록 보여지도록 함.
답변자 -> 지원자.

fix: 다중태그 지원

fix: 검색 자동완성 및 ui 다듬기

fix: 복구

fix: ui디자인 변경 어드민 기능 삭제

feat: 스터디 튜토리얼 추가

refactor: 코드리팩토링

refactor: 코드 리팩토링

fix: ui 수정

🍀 참고사항

동주님이 qa 주신 리팩토링을 진행하였습니다.(밸런스게임 태그검색, 나의 1대1 스터디 히스토리에 description넣기)

그리고 튜토리얼, 명예의전당에 ? 설명 버튼을 세부기능으로 추가하였습니다.

스크린샷 (선택)

image image image image image image image image

Summary by CodeRabbit

릴리스 노트

  • 새 기능

    • 밸런스 게임에 태그 기반 필터링이 추가되었습니다.
    • 아카이브에 검색 제안 및 가시성 토글 기능이 추가되었습니다.
    • 1:1 스터디 기록을 캘린더 또는 목록으로 조회할 수 있습니다.
    • 새로운 사용자를 위한 스터디 가이드 투토리얼이 추가되었습니다.
    • 명예의 전당 랭킹 시스템이 추가되었습니다.
  • 변경 사항

    • 주간 인사이트 네비게이션 항목이 제거되었습니다.
    • 토스트 메시지 위치 및 스타일이 업데이트되었습니다.

아카이브 ui 보완
밸런스 게임 > url 공유 기능 (투표 글 하나에 대해서 외부로 공유 가능하게끔)
밸런스게임에서 프사가 안보임 해결
나의 스터디목록 ui에 답변자 -> 지원자 이렇게 문구 바꿈.
밸런스게임에 태그 필터 기능 추가.
밸런스게임 글 작성에 기존 태그 목록 보여지도록 함.
답변자 -> 지원자.

fix: 다중태그 지원

fix: 검색  자동완성 및 ui 다듬기

fix: 복구

fix: ui디자인 변경 어드민 기능 삭제

feat: 스터디 튜토리얼 추가

refactor: 코드리팩토링

refactor: 코드 리팩토링

fix: ui 수정
@Hyeonjun0527 Hyeonjun0527 self-assigned this Feb 4, 2026
@vercel
Copy link

vercel bot commented Feb 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
study-platform-client-dev Ready Ready Preview, Comment Feb 4, 2026 1:21pm

@coderabbitai
Copy link

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

변경사항 분석

📋 상세 설명

새로운 API 로거, 문서화, 다수의 UI 컴포넌트, 아카이브 기능, 밸런스 게임 태그 필터링, 스터디 튜토리얼, 명예의 전당 및 스터디 히스토리 UI 리팩토링을 포함한 대규모 기능 추가 및 리팩토링.

📊 변경사항

Cohort / File(s) 요약
API 로거 및 통합
src/api/client/api-logger.ts, src/api/client/axios.server.ts, src/api/client/axios.ts, src/api/client/axiosV2.server.ts, src/api/client/axiosV2.ts
새 API 로거 유틸리티 추가, 모든 Axios 인스턴스에 요청/응답 로깅 인터셉터 통합
UI 컴포넌트 라이브러리 (필터/입력)
src/components/ui/filters/filter-pill-button.tsx, src/components/ui/filters/sort-dropdown.tsx, src/components/ui/filters/view-mode-toggle.tsx, src/components/ui/input/base.tsx
필터링 및 정렬 UI 컴포넌트 신규 추가, 기존 입력 컴포넌트 제어 상태 처리 강화
UI 컴포넌트 라이브러리 (일반)
src/components/ui/action-pill-button.tsx, src/components/ui/inline-section-header.tsx, src/components/ui/section-header.tsx, src/components/ui/section-shell.tsx, src/components/ui/stat-item.tsx, src/components/ui/modal-shell.tsx, src/components/ui/toast.tsx, src/components/ui/tooltip/index.tsx
새 섹션 헤더, 통계 항목, 모달 셸 컴포넌트 추가; 토스트 스타일 및 툴팁 지연 시간 설정 개선
튜토리얼 오버레이
src/components/tutorial/tutorial-overlay.tsx, src/components/tutorial/index.ts
다단계 튜토리얼 오버레이 컴포넌트 신규 구현, 스포트라이트 효과, 자동 스크롤, 키보드 네비게이션 포함
투표/밸런스 게임 UI
src/components/card/voting-card.tsx, src/components/voting/vote-timer.tsx, src/components/voting/voting-create-modal.tsx, src/components/voting/voting-modal-header.tsx, src/components/voting/voting-modal-footer.tsx, src/components/voting/voting-option-fields.tsx, src/components/voting/voting-tag-field.tsx, src/components/voting/voting-deadline-field.tsx, src/components/voting/voting-edit-modal.tsx, src/components/voting/voting-detail-view.tsx
투표 카드에 태그 클릭 콜백 추가; 모달 리팩토링으로 FormProvider 기반 구조로 전환; 태그 제안, 마감일 필드 신규 추가; 공유 기능 구현
아카이브 API 및 모델
src/features/study/one-to-one/archive/api/get-archive-search-suggestions.ts, src/features/study/one-to-one/archive/api/toggle-visibility.ts, src/features/study/one-to-one/archive/api/update-archive.ts, src/features/study/one-to-one/archive/model/archive-keys.ts, src/features/study/one-to-one/archive/model/use-archive-query.ts, src/features/study/one-to-one/archive/model/use-archive-search-suggestions-query.ts, src/features/study/one-to-one/archive/model/use-archive-actions.ts, src/features/study/one-to-one/archive/model/use-*/mutation.ts
아카이브 검색, 공개/비공개 전환, 업데이트 API 신규 추가; React Query 키 구조 통합, 뮤테이션 훅 구현
아카이브 UI
src/features/study/one-to-one/archive/ui/archive-header.tsx, src/features/study/one-to-one/archive/ui/archive-filters.tsx, src/features/study/one-to-one/archive/ui/archive-grid.tsx, src/features/study/one-to-one/archive/ui/archive-list.tsx, src/features/study/one-to-one/archive/ui/archive-pagination.tsx, src/features/study/one-to-one/archive/ui/archive-tab-client.tsx, src/features/study/one-to-one/archive/ui/use-archive-filters.ts
아카이브 목록/그리드 뷰 인라인 편집 기능 추가; 필터링, 검색, 페이지네이션 UI 컴포넌트 신규 구현
밸런스 게임 API 및 모델
src/features/study/one-to-one/balance-game/api/balance-game-api.server.ts, src/features/study/one-to-one/balance-game/api/balance-game-api.ts, src/features/study/one-to-one/balance-game/api/get-balance-game-search-suggestions.ts, src/features/study/one-to-one/balance-game/const/tags.ts, src/features/study/one-to-one/balance-game/model/balance-game-keys.ts, src/features/study/one-to-one/balance-game/model/use-balance-game-query.ts, src/features/study/one-to-one/balance-game/model/use-balance-game-mutation.ts, src/features/study/one-to-one/balance-game/model/use-balance-game-search-suggestions-query.ts
태그/검색 필터링, 태그 제안 API 추가; React Query 키 구조 재설계; 태그 길이 제약 상수 정의
밸런스 게임 UI
src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx, src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx, src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts, src/features/study/one-to-one/balance-game/ui/use-infinite-scroll.ts, src/features/study/one-to-one/balance-game/ui/filter-pill-button.tsx, src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx, src/features/study/one-to-one/balance-game/ui/balance-game-page.tsx
태그 오토컴플리트, 필터링 바, 무한 스크롤 훅 신규 구현; 커뮤니티 탭 리팩토링으로 상세 뷰 라우팅 추가; 기존 페이지 컴포넌트 제거
스터디 스케줄/튜토리얼
src/features/study/one-to-one/schedule/model/study-tutorial-steps.ts, src/features/study/one-to-one/schedule/model/tutorial-mock.ts, src/features/study/one-to-one/schedule/ui/study-tutorial-controller.tsx, src/features/study/one-to-one/schedule/ui/study-card.tsx, src/features/study/one-to-one/schedule/ui/today-study-card.tsx, src/features/study/one-to-one/schedule/ui/home-study-tab.tsx
튜토리얼 모드 기능 추가, 가이드 스텝 정의, 모의 데이터 제공; StudyCard에 조건부 UI 렌더링 구현
명예의 전당
src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header.tsx, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx, src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx, src/features/study/one-to-one/hall-of-fame/ui/ranker-list-item.tsx, src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx, src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-tab-client.tsx
명예의 전당 UI 모듈화 리팩토링, 순위 배지, 랭커 목록 항목, MVP 팀 카드 컴포넌트 신규 추가
스터디 히스토리
src/features/study/one-to-one/history/ui/study-history-header.tsx, src/features/study/one-to-one/history/ui/study-history-calendar-section.tsx, src/features/study/one-to-one/history/ui/study-history-list-section.tsx, src/features/study/one-to-one/history/ui/study-history-pagination.tsx, src/features/study/one-to-one/history/ui/study-history-summary.tsx, src/features/study/one-to-one/history/ui/study-history-utils.ts, src/features/study/one-to-one/history/ui/study-history-tab-client.tsx
스터디 히스토리 UI 모듈화 리팩토링, 리스트/캘린더 뷰 토글, 페이지네이션, 데이터 맵핑 유틸리티 추가
스터디 모달 개선
src/features/study/interview/ui/study-done-modal.tsx, src/features/study/interview/ui/study-ready-modal.tsx
ModalShell 래퍼 도입, forceOpen 제어 추가, 폼 상태 동기화 콜백 구현
기존 컴포넌트 업데이트
src/components/discussion/comment-list.tsx, src/components/home/tab-navigation.tsx, src/components/section/my-participating-studies-section.tsx, src/components/study-history/study-history-row.tsx, src/app/(service)/home/page.tsx, src/app/(service)/insights/page.tsx, src/app/(service)/insights/weekly/[id]/page.tsx, src/app/(service)/insights/weekly/page.tsx, src/features/study/one-to-one/ui/one-on-one-page.tsx, src/features/study/participation/ui/reservation-list.tsx, src/features/study/group/channel/ui/sub-comments.tsx
프로필 이미지 처리 개선, 홈 콘텐츠 스크롤 훅 추가, 역할 배지 텍스트 변경, 주간 인사이트 네비게이션 제거
유틸리티 및 훅
src/hooks/use-scroll-to-home-content.ts, src/utils/voting-id.ts, src/widgets/home/study-list-table.tsx
홈 콘텐츠 스크롤 훅 추가 (안정화 메커니즘 포함), 투표 ID 인코딩/디코딩 유틸리티 추가, StudyListSection에 튜토리얼 모드 지원
타입 정의
src/types/archive.ts, src/types/balance-game.ts, src/types/schemas/zod-schema.ts
아카이브 및 밸런스 게임 타입 확장 (프로필 이미지, 검색 응답, API 응답 구조), 태그 길이 검증 추가
문서 및 의존성
docs/balance-game-tag-filter-request.md, package.json
태그 필터링 기능 문서화, hashids 라이브러리 의존성 추가

🔀 시퀀스 다이어그램

sequenceDiagram
    actor User
    participant UI as 밸런스 게임 UI
    participant Hook as useBalanceGameFilters<br/>Hook
    participant API as API Client
    participant Server as Backend

    User->>UI: 태그 입력
    UI->>Hook: addTag(tag)
    Hook->>Hook: 상태 업데이트 (selectedTags)
    Hook->>UI: onChange 콜백 실행
    
    UI->>API: getBalanceGameList(tags, q)
    API->>Server: GET /api/v1/balance-game?tags=tag1,tag2&q=search
    Server-->>API: BalanceGameListResponse
    API-->>UI: 필터링된 목록 반환
    
    UI->>User: 업데이트된 결과 렌더링

    User->>UI: 태그 제거
    UI->>Hook: removeTag(tag)
    Hook->>Hook: selectedTags 업데이트
    Hook->>UI: onChange 콜백
    UI->>API: getBalanceGameList(remaining_tags)
    API->>Server: GET /api/v1/balance-game?tags=tag1&q=search
    Server-->>API: BalanceGameListResponse
    API-->>UI: 업데이트된 결과
    UI->>User: 필터링된 목록 표시
Loading
sequenceDiagram
    actor User as 사용자
    participant UI as 아카이브 UI
    participant State as useArchiveFilters<br/>Hook
    participant Query as React Query
    participant API as API Client
    participant Server as Backend

    User->>UI: 아카이브 아이템 표시
    UI->>State: 초기 필터링 상태 설정
    State-->>UI: { viewMode, showMyOnly, ... }
    
    User->>UI: 아이템 수정 버튼 클릭
    UI->>UI: 편집 모드 활성화
    User->>UI: 제목/설명 수정
    User->>UI: 저장 클릭
    
    UI->>Query: updateArchive(id, request)
    Query->>API: PATCH /archive/{id}
    API->>Server: UpdateArchiveRequest
    Server-->>API: UpdateArchiveResponse
    API-->>Query: 응답 처리
    
    Query->>Query: 낙관적 업데이트 적용
    Query->>Query: 캐시 무효화
    Query-->>UI: 업데이트된 데이터
    UI->>User: 성공 피드백 표시
Loading
sequenceDiagram
    actor User as 사용자
    participant UI as StudyCard
    participant Controller as StudyTutorialController
    participant Overlay as TutorialOverlay
    participant Steps as STUDY_TUTORIAL_STEPS

    User->>UI: URL에 tutorial=study 파라미터 추가
    UI->>Controller: 마운트 & tutorial 쿼리 감지
    Controller->>Controller: 튜토리얼 활성화
    
    Controller->>Steps: 현재 stepIndex에 따른 시나리오 조회
    Steps-->>Controller: StudyTutorialScenario 반환
    
    Controller->>UI: forcedStatus, forcedRole 등 프롭 전달
    UI->>UI: 튜토리얼 모드로 렌더링
    
    Controller->>Overlay: steps, activeIndex, 핸들러 전달
    Overlay->>Overlay: 스포트라이트 효과 적용 (targetSelector)
    
    User->>Overlay: 다음 버튼 클릭
    Overlay->>Controller: onStepChange(nextIndex)
    Controller->>UI: 다음 시나리오로 상태 업데이트
    UI->>UI: 조건부 모달 렌더링 (forceOpenReadyModal 등)
    
    loop 단계별 진행
        User->>Overlay: 튜토리얼 진행
        Overlay->>UI: 상태 업데이트
    end
    
    User->>Overlay: 완료 또는 닫기
    Overlay->>Controller: onClose 또는 onFinish
    Controller->>Controller: URL에서 tutorial 파라미터 제거
    Controller-->>UI: /home으로 네비게이션
Loading

🎯 코드 리뷰 난이도

🎯 4 (복잡) | ⏱️ ~60분

근거:

  • 다양한 도메인에 걸친 광범위한 변경 (API, 컴포넌트, 훅, 타입)
  • 새로운 기능 3개 (아카이브 편집, 밸런스 게임 태그 필터링, 스터디 튜토리얼) 포함
  • React Query 키 구조 리팩토링과 뮤테이션 로직 여러 곳에서 변경
  • 조건부 렌더링과 상태 관리 복잡도 높음 (튜토리얼 모드, 편집 모드)
  • 많은 새 컴포넌트와 훅 추가로 인한 통합 검증 필요

💡 관련 PR

🐰 시인의 축하시

🎉 태그로 필터링하고, 아카이브 수정하며,
튜토리얼 스텝을 따라가는 우리의 여정!
UI 모듈화로 코드 정리 반짝반짝,
밤샘 코딩 끝, 이제 배포할 시간 이야 🚀

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title check ⚠️ Warning PR 제목 '서버 클라이언트 hydration 불일치 오류, 시간문제 해결'은 버그 수정에 초점을 맞추고 있으나, 실제 변경사항은 아카이브 UI 보완, 밸런스 게임 태그 필터 및 공유 기능, 튜토리얼, 명예의 전당 등 광범위한 기능 추가와 리팩토링을 포함하고 있어 제목과 실제 내용이 상당히 불일치합니다. PR 제목을 변경사항의 범위를 더 정확히 반영하도록 수정하세요. 예: 'feat: 아카이브, 밸런스 게임, 튜토리얼 기능 추가 및 여러 UI 개선'과 같이 주요 변경사항을 포괄하는 제목으로 변경하시기 바랍니다.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-to-one-new-feature-bug

Comment @coderabbitai help to get the list of available commands and usage tips.

@Hyeonjun0527 Hyeonjun0527 merged commit 593f334 into develop Feb 4, 2026
14 of 16 checks passed
@Hyeonjun0527 Hyeonjun0527 deleted the fix/one-to-one-new-feature-bug branch February 4, 2026 13:26
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/types/balance-game.ts (1)

1-14: ⚠️ Potential issue | 🔴 Critical

Import 문이 파일 상단에 위치해야 합니다

import type { ProfileImage } 문이 BalanceGameOption 인터페이스 정의 이후(Line 8)에 위치해 있습니다. TypeScript/JavaScript에서 모든 import 문은 파일 최상단에 있어야 합니다.

🔧 수정 제안
+import type { ProfileImage } from '@/entities/user/api/types';
+
 export interface BalanceGameOption {
   id: number;
   label: string;
   voteCount: number;
   percentage: number;
 }

-import type { ProfileImage } from '@/entities/user/api/types';
-
 export interface BalanceGameAuthor {
src/api/client/axios.ts (1)

3-28: ⚠️ Potential issue | 🟡 Minor

경로 별칭을 사용하여 import 문 수정

인터셉터 실행 순서는 올바릅니다. 로거 인터셉터가 먼저 추가되고(요청은 LIFO, 응답은 FIFO 순서), onRequestClient가 나중에 추가되므로 실행 순서가 의도대로 작동합니다. 다만 @/* 경로 별칭을 사용하도록 import 문을 수정해야 합니다.

Line 3: import { attachApiLogger } from '@/api/client/api-logger';

🤖 Fix all issues with AI agents
In `@src/api/client/api-logger.ts`:
- Around line 44-81: The request/response logging in
instance.interceptors.request.use and instance.interceptors.response.use
currently prints params/body via stringifyParams and stringifyData which can
expose PII/tokens; make logging of params and response body opt-in via a
configurable boolean (e.g., enableDetailedApiLogging or per-client option) and,
when disabled, either omit those fields or replace them using a masking function
(e.g., maskSensitiveData) before calling console.log; update usages around the
Axios instance creation and the label-aware logs so that logs still include
method/url/status but only include params/data when the opt-in flag is true or
after masking.

In `@src/components/voting/voting-deadline-field.tsx`:
- Around line 23-30: getTodayDateString() is being evaluated during SSR and on
the client causing timezone-based hydration mismatches; change the code so the
date string is computed only on the client (e.g., create a state like
clientMinDate and set it inside useEffect on mount) and use that state for the
input's min prop (the current usage around the input's min at line ~41). Keep
getTodayDateString as a helper but call it only inside the client-side effect
(or initialize state with undefined and set to getTodayDateString() in
useEffect) so server-side render omits/minimizes the min attribute and the
client updates it after hydration. Ensure references to getTodayDateString and
the input's min prop are updated accordingly.

In `@src/features/study/one-to-one/archive/ui/archive-pagination.tsx`:
- Around line 22-36: Add accessible labels to the icon-only pagination buttons
by extending PaginationCircleButtonProps to include an optional aria-label?:
string, updating the PaginationCircleButton component to accept and pass that
prop through onto the underlying button element (e.g., <button
aria-label={props["aria-label"]} ...>), and then set aria-label="이전 페이지" on the
left instance and aria-label="다음 페이지" on the right instance where
PaginationCircleButton is used in archive-pagination.tsx; ensure the prop typing
and JSX spread/explicit attribute are updated so TypeScript and JSX pass the
label to the DOM.

In `@src/features/study/one-to-one/archive/ui/archive-tab-client.tsx`:
- Around line 59-76: The archiveParams currently sets authorId to undefined even
when showMyOnly is true; import and call the useAuth hook to get memberId, and
set archiveParams.authorId = memberId when showMyOnly is true so the backend
receives the current user ID; update any related equality check
(shouldUseInitialData) that compares archiveParams.authorId to
initialParams.authorId to account for the populated memberId; ensure you
reference archiveParams, showMyOnly, authorId, useAuth, memberId and
shouldUseInitialData when making the changes.

In `@src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts`:
- Around line 3-58: The current implementation calls triggerChange
(options?.onChange) inside setState updaters (in setStatus, setSort, addTag,
removeTag), causing onChange to run before state updates commit; move the
onChange invocation into a useEffect that watches [statusFilter, sortMode,
selectedTags] so onChange runs after state changes. Concretely: stop calling
triggerChange from inside the updater functions in useBalanceGameFilters (remove
triggerChange calls from setStatus, setSort, addTag, removeTag), keep or remove
the triggerChange helper as desired, and add a useEffect that calls
options?.onChange() whenever statusFilter, sortMode, or selectedTags change
(ensuring onChange is not called unnecessarily when values don't actually
change).

In `@src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx`:
- Around line 49-53: Replace the hardcoded "1월 4주차 MVP 팀" span in
mvp-team-card.tsx with a dynamic string built from the team's date fields (e.g.,
use team.weekDate or weekStartDate/weekEndDate) and format it with the project's
date formatter (or Intl.DateTimeFormat) before rendering; update the JSX inside
the span in the MVPTeamCard component to compute the week label (e.g., format
weekStart/weekEnd or parse team.weekDate and produce "M월 N주차") and render that
value instead of the fixed text.

In `@src/features/study/one-to-one/history/ui/study-history-utils.ts`:
- Around line 8-28: mapHistoryItem uses new Date() and getFullYear/getDay which
rely on client local timezone and can shift scheduledAt dates; change it to use
Intl.DateTimeFormat with a fixed timeZone (e.g., 'Asia/Seoul') to derive the
year, month, day and weekday reliably. Inside mapHistoryItem replace
creation/usage of dateObj and manual getFullYear/getMonth/getDate/getDay with
Intl.DateTimeFormat('ko-KR', {timeZone: 'Asia/Seoul', year:'numeric',
month:'2-digit', day:'2-digit', weekday:'short'}) or formatToParts to extract
parts, then construct date string and weekday from those parts so date/`date`
and `date`-related values (and any weekday mapping) are computed in the Seoul
timezone.
🟡 Minor comments (14)
src/features/study/participation/ui/reservation-list.tsx-117-127 (1)

117-127: ⚠️ Potential issue | 🟡 Minor

총 인원 표시가 undefined로 노출될 수 있습니다.

데이터 로딩 전에는 data?.totalundefined라 사용자에게 "총 undefined명"이 보일 수 있어요. 동시에 Tailwind 클래스 순서도 가이드에 맞게 정렬해주세요.

🛠️ 제안 수정
-          <div className="text-text-subtlest font-designer-24b">
-            총 {data?.total}명
+          <div className="font-designer-24b text-text-subtlest">
+            총 {data?.total ?? 0}명
           </div>
...
-        titleClassName="text-text-strong font-designer-28b"
+        titleClassName="font-designer-28b text-text-strong"

As per coding guidelines: **/*.{tsx,ts,jsx,js}: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups`.

src/components/ui/filters/view-mode-toggle.tsx-1-2 (1)

1-2: ⚠️ Potential issue | 🟡 Minor

'use client' 지시어 누락

이 컴포넌트는 onClick 이벤트 핸들러를 사용하므로 클라이언트 컴포넌트입니다. Next.js App Router에서 클라이언트 상호작용을 위해 'use client' 지시어를 추가해야 합니다.

🔧 수정 제안
+'use client';
+
 import React from 'react';
 import { cn } from '@/components/ui/(shadcn)/lib/utils';
src/components/ui/filters/sort-dropdown.tsx-1-2 (1)

1-2: ⚠️ Potential issue | 🟡 Minor

'use client' 지시어 누락

이 컴포넌트는 onClick 이벤트 핸들러를 사용하므로 클라이언트 컴포넌트입니다.

🔧 수정 제안
+'use client';
+
 import React from 'react';
 import { cn } from '@/components/ui/(shadcn)/lib/utils';
src/components/voting/voting-modal-header.tsx-24-29 (1)

24-29: ⚠️ Potential issue | 🟡 Minor

아이콘 닫기 버튼에 접근성 라벨 추가

현재 아이콘만 있어 스크린리더가 버튼 의미를 알기 어렵습니다. aria-label(또는 숨김 텍스트)을 추가해주세요.

✅ 수정 예시
         <button
           onClick={onClose}
           disabled={disabled}
+          aria-label="닫기"
           className="rounded-100 text-text-subtle hover:bg-fill-neutral-subtle-default hover:text-text-strong p-100 transition-colors disabled:cursor-not-allowed"
         >
src/features/study/one-to-one/archive/model/use-bookmark-mutation.ts-30-35 (1)

30-35: ⚠️ Potential issue | 🟡 Minor

북마크 수 음수 방지 필요

Line 33-35에서 기존 bookmarks가 0/undefined인데 isBookmarked=true인 경우 -1로 내려갈 수 있습니다. 최소 0으로 클램프해주세요.

✅ 수정 제안
-                    bookmarks: item.isBookmarked
-                      ? (item.bookmarks ?? 0) - 1
-                      : (item.bookmarks ?? 0) + 1,
+                    bookmarks: item.isBookmarked
+                      ? Math.max(0, (item.bookmarks ?? 0) - 1)
+                      : (item.bookmarks ?? 0) + 1,
src/features/study/one-to-one/archive/api/update-archive.ts-16-25 (1)

16-25: ⚠️ Potential issue | 🟡 Minor

응답 payload 누락 시 undefined 반환 위험이 있습니다.

Line 20-25에서 data.data와 data.content가 모두 없으면 undefined가 반환됩니다. 호출부 런타임 오류 방지를 위해 payload 체크 후 에러 처리(또는 기본값)를 추가하고 반환 타입을 명시해주세요.

🛡️ 제안 수정안
-export const updateArchive = async (
+export const updateArchive = async (
   id: number,
   request: UpdateArchiveRequest,
-) => {
+): Promise<UpdateArchiveResponse> => {
   const { data } = await axiosInstance.patch<{
     data?: UpdateArchiveResponse;
     content?: UpdateArchiveResponse;
   }>(`/archive/${id}`, request);
 
-  return data.data ?? data.content;
+  const payload = data.data ?? data.content;
+  if (!payload) {
+    throw new Error('Update archive response is empty');
+  }
+  return payload;
 };
docs/balance-game-tag-filter-request.md-11-13 (1)

11-13: ⚠️ Potential issue | 🟡 Minor

tag/tags 표기 불일치 수정 필요

변경 요약의 tag 표기가 API 섹션의 tags와 충돌합니다. 문서 전반을 실제 쿼리명으로 통일해 주세요.

✏️ 수정 제안
- - 프론트는 태그 입력 후 Enter 또는 "적용" 버튼 클릭 시 `tag` 쿼리로 요청
+ - 프론트는 태그 입력 후 Enter 또는 "적용" 버튼 클릭 시 `tags` 쿼리로 요청
- - 태그 필터 해제 시 `tag` 파라미터 제거
+ - 태그 필터 해제 시 `tags` 파라미터 제거
docs/balance-game-tag-filter-request.md-44-46 (1)

44-46: ⚠️ Potential issue | 🟡 Minor

코드 펜스 언어 지정 필요 (MD040)

정적 분석 경고가 있어 예시 요청의 코드 블록에 언어를 지정해 주세요.

✏️ 수정 제안
-```
+```http
 GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
-```
+```
src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx-236-248 (1)

236-248: ⚠️ Potential issue | 🟡 Minor

선택된 태그 버튼에 border 클래스 누락

Line 242의 선택된 태그 버튼에 border 클래스가 없어서 border-border-subtle 스타일이 적용되지 않습니다. balance-game-filters-bar.tsx의 동일한 패턴과 일관성을 위해 추가가 필요합니다.

🔧 수정 제안
           <button
             key={tag}
             onClick={() => onRemoveTag(tag)}
-            className="rounded-100 border-border-subtle font-designer-13b text-text-subtle hover:border-border-brand hover:text-text-brand max-w-full truncate px-200 py-150 transition-colors"
+            className="rounded-100 border border-border-subtle font-designer-13b text-text-subtle hover:border-border-brand hover:text-text-brand max-w-full truncate px-200 py-150 transition-colors"
           >
src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx-347-358 (1)

347-358: ⚠️ Potential issue | 🟡 Minor

선택된 태그 버튼에 border 스타일 누락

선택된 태그를 표시하는 버튼(Line 354)에 border 클래스가 없어서 border-border-subtle 스타일이 적용되지 않습니다.

🔧 수정 제안
             <button
               key={tag}
               type="button"
               onClick={() => onRemoveTag(tag)}
-              className="rounded-100 border-border-subtle font-designer-13b text-text-subtle hover:border-border-brand hover:text-text-brand px-200 py-150 transition-colors"
+              className="rounded-100 border border-border-subtle font-designer-13b text-text-subtle hover:border-border-brand hover:text-text-brand px-200 py-150 transition-colors"
             >
src/features/study/one-to-one/archive/ui/archive-grid.tsx-75-81 (1)

75-81: ⚠️ Potential issue | 🟡 Minor

handleCancel에서 descriptionnull 처리 누락

archive-list.tsx와 동일한 이슈입니다. item.descriptionnull일 수 있지만 state는 string 타입입니다.

🐛 수정 제안
  const handleCancel = (e: React.MouseEvent) => {
    e.stopPropagation();
    setIsEditing(false);
    setTitle(item.title);
-   setDescription(item.description);
+   setDescription(item.description ?? '');
    setNextPrivate(!!item.isPrivate);
  };
src/features/study/one-to-one/balance-game/api/balance-game-api.ts-24-25 (1)

24-25: ⚠️ Potential issue | 🟡 Minor

댓글 목록 조회 엔드포인트에 페이지네이션 설명 추가 필요

getBalanceGameComments 함수(101줄)에서 page = 0을 사용하는 이유에 대한 설명이 없습니다. 게임 목록 조회(getBalanceGameList)는 백엔드가 1부터 시작하는 페이지를 사용하고, 댓글 목록 조회는 0부터 시작하는 페이지를 사용하는 것으로 보아 두 엔드포인트가 다른 페이지네이션 방식을 지원하는 것으로 확인됩니다. 코드 의도를 명확히 하기 위해 101줄에 페이지 인덱싱 차이에 대한 주석을 추가해주세요.

src/features/study/interview/ui/study-ready-modal.tsx-199-205 (1)

199-205: ⚠️ Potential issue | 🟡 Minor

'참고 자료' 필드의 required 여부를 문구와 일치시켜 주세요.
helper는 선택 입력을 암시하지만 required가 설정돼 있어 혼란이 생길 수 있습니다. 선택 입력이면 required를 제거하고, 필수라면 helper 문구를 수정해 주세요.

🧩 선택 입력 기준 수정 예시
           <FormField<StudyReadyFormValues, 'link'>
             name="link"
             label="참고 자료"
             helper="함께 참고할 문서나 링크가 있다면 입력해 주세요"
-            required
             direction="vertical"
           >
src/components/ui/modal-shell.tsx-32-34 (1)

32-34: ⚠️ Potential issue | 🟡 Minor

닫기 아이콘에 접근성 레이블을 추가해 주세요.
아이콘만 있는 버튼은 스크린리더에서 이름이 비어 있을 수 있습니다. aria-label 또는 sr-only 텍스트를 추가해 주세요.

♿️ 접근성 레이블 추가
-            <Modal.Close>
+            <Modal.Close aria-label="닫기">
               <XIcon />
             </Modal.Close>
🧹 Nitpick comments (40)
src/widgets/home/study-list-table.tsx (1)

69-98: 사용자 정보 표시 JSX가 mapDailyStudyToDisplayData 함수와 중복됩니다.

지원자/면접관 표시 JSX 구조(lines 73-78, 79-84)가 기존 mapDailyStudyToDisplayData 함수(lines 28-35, 36-43)와 동일합니다. 현재 튜토리얼용 정적 데이터이므로 큰 문제는 아니지만, 향후 유지보수를 위해 헬퍼 함수 추출을 고려해볼 수 있습니다.

♻️ 사용자 정보 표시 헬퍼 함수 추출 제안
+function renderUserInfo(image: string, name: string) {
+  return (
+    <div className="flex items-center gap-150 px-100 py-50">
+      <UserAvatar image={image} />
+      <span className="font-designer-14m whitespace-nowrap">{name}</span>
+    </div>
+  );
+}
+
 function mapDailyStudyToDisplayData(
   row: DailyStudy,
   index: number,
 ): Record<Header, React.ReactNode> {
   return {
     조: index + 1,
-    지원자: (
-      <div className="flex items-center gap-150 px-100 py-50">
-        <UserAvatar image={row.intervieweeImage} />
-        <span className="font-designer-14m whitespace-nowrap">
-          {row.interviewee}
-        </span>
-      </div>
-    ),
+    지원자: renderUserInfo(row.intervieweeImage, row.interviewee),
     // ... 면접관도 동일하게 적용
   };
 }
src/components/ui/input/base.tsx (1)

103-110: 비제어 모드에서 문자 수 표시 확인 필요

hideMetafalse일 때 current.length를 표시하는데, 비제어 모드에서는 current가 빈 문자열이므로 실제 입력 값과 다른 "0/{maxLength}"가 표시될 수 있습니다.

💡 비제어 모드에서 ref를 통한 값 접근 고려

비제어 모드에서도 정확한 문자 수를 표시하려면 ref를 통해 실제 입력 값을 읽어야 합니다. 현재 hideMeta의 기본값이 true이므로 대부분의 경우 문제가 되지 않지만, 비제어 모드에서 meta를 표시해야 하는 경우 주의가 필요합니다.

src/components/discussion/comment-list.tsx (1)

93-97: as any 타입 캐스팅 대신 타입 가드 사용 권장

프로필 이미지 추출 로직이 두 가지 형식(문자열 URL, resizedImages 객체)을 지원하도록 개선되었으나, as any 사용은 타입 안전성을 저하시킵니다.

♻️ 타입 안전한 헬퍼 함수 제안
+// 파일 상단 또는 별도 유틸리티로 분리
+function getAuthorImage(author: DiscussionComment['author'] | VotingComment['author'] | BalanceGameComment['author']): string | undefined {
+  if ('avatar' in author && author.avatar) {
+    return author.avatar;
+  }
+  if ('profileImage' in author) {
+    const img = author.profileImage;
+    if (typeof img === 'string') return img;
+    if (img && typeof img === 'object' && 'resizedImages' in img) {
+      return img.resizedImages?.[0]?.resizedImageUrl;
+    }
+  }
+  return undefined;
+}

 // Author image handling
-const authorImage =
-  'avatar' in comment.author
-    ? comment.author.avatar
-    : 'profileImage' in comment.author
-      ? typeof (comment.author as any).profileImage === 'string'
-        ? (comment.author as any).profileImage
-        : (comment.author as any).profileImage?.resizedImages?.[0]
-            ?.resizedImageUrl
-      : undefined;
+const authorImage = getAuthorImage(comment.author);
src/components/voting/voting-edit-modal.tsx (1)

30-30: 상수를 컴포넌트 외부로 추출 권장

TAG_MAX_LEN이 컴포넌트 내부에 정의되어 있어 렌더링마다 재생성됩니다. 또한 AI 요약에 따르면 다른 balance-game 모듈에도 BALANCE_GAME_TAG_MAX_LEN = 40 상수가 있습니다.

♻️ 공유 상수 사용 제안
+'use client';

+import { BALANCE_GAME_TAG_MAX_LEN } from '@/constants/balance-game'; // 또는 해당 상수 위치
 import { zodResolver } from '@hookform/resolvers/zod';
 // ... other imports

 export default function VotingEditModal({
   // ...
 }: VotingEditModalProps) {
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [tagInput, setTagInput] = useState('');
-  const TAG_MAX_LEN = 40;

또는 해당 상수 파일이 없다면 컴포넌트 외부로만 이동:

+'use client';
+
+const TAG_MAX_LEN = 40;

 import { zodResolver } from '@hookform/resolvers/zod';
src/features/study/one-to-one/history/ui/study-history-pagination.tsx (1)

24-26: 접근성 개선을 위한 선택적 제안

유니코드 화살표 , 는 시각적으로는 잘 작동하지만, 스크린 리더 사용자를 위해 aria-label을 추가하거나 Lucide 아이콘(ChevronLeft, ChevronRight)을 사용하는 것을 고려해 볼 수 있습니다.

♿ 접근성 개선 제안
       <PaginationCircleButton
         onClick={() => onPageChange(Math.max(1, currentPage - 1))}
         disabled={currentPage === 1}
+        aria-label="이전 페이지"
       >
         ←
       </PaginationCircleButton>
       {/* ... */}
       <PaginationCircleButton
         onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
         disabled={currentPage === totalPages}
+        aria-label="다음 페이지"
       >
         →
       </PaginationCircleButton>

Also applies to: 33-35

src/features/study/one-to-one/history/ui/study-history-header.tsx (1)

58-85: ViewModeToggle 컴포넌트 재사용 권장

src/components/ui/filters/view-mode-toggle.tsx에 동일한 기능의 ViewModeToggle 컴포넌트가 존재합니다. 코드 중복을 줄이고 일관성을 위해 해당 컴포넌트를 재사용하는 것을 권장합니다.

또한, 접근성 향상을 위해 토글 버튼에 aria-pressed 속성을 추가하는 것이 좋습니다.

♻️ ViewModeToggle 재사용 제안
+import ViewModeToggle from '@/components/ui/filters/view-mode-toggle';
+import { List, Calendar as CalendarIcon } from 'lucide-react';

// rightSlot에서 사용:
+const viewModeOptions = [
+  { value: 'LIST' as const, icon: <List className="h-4 w-4" />, title: '리스트' },
+  { value: 'CALENDAR' as const, icon: <CalendarIcon className="h-4 w-4" />, title: '달력' },
+];

// SectionHeader의 rightSlot prop:
-rightSlot={
-  <div className="bg-background-default rounded-100 border-border-subtle flex shrink-0 border p-50">
-    <button
-      onClick={() => onViewModeChange('LIST')}
-      ...
-    </button>
-    ...
-  </div>
-}
+rightSlot={
+  <ViewModeToggle
+    value={viewMode}
+    options={viewModeOptions}
+    onChange={onViewModeChange}
+  />
+}
src/components/voting/voting-option-fields.tsx (1)

57-65: 접근성: 삭제 버튼에 접근 가능한 레이블 추가 권장

스크린 리더 사용자를 위해 삭제 버튼에 aria-label 속성을 추가하는 것이 좋습니다.

♿ 접근성 개선 제안
 <button
   type="button"
   onClick={() => remove(index)}
+  aria-label={`선택지 ${index + 1} 삭제`}
   className="rounded-100 text-text-subtle hover:bg-fill-critical-subtle-default hover:text-text-critical p-150 transition-colors"
 >
   <Trash2 className="h-4 w-4" />
 </button>
src/components/ui/filters/view-mode-toggle.tsx (1)

34-46: 접근성: aria-pressed 속성 추가 권장

토글 버튼의 활성화 상태를 스크린 리더에 전달하기 위해 aria-pressed 속성을 추가하는 것이 좋습니다.

♿ 접근성 개선 제안
 <button
   key={option.value}
   onClick={() => onChange(option.value)}
+  aria-pressed={isActive}
   className={cn(
     'rounded-75 p-100 transition-colors',
     isActive
       ? 'bg-fill-neutral-default-default text-text-strong shadow-sm'
       : 'text-text-subtlest hover:text-text-subtle',
   )}
   title={option.title}
 >
src/components/ui/filters/sort-dropdown.tsx (1)

28-52: 접근성: hover 기반 드롭다운의 키보드/모바일 접근성 고려

현재 group-hover:block 방식은 마우스 hover에만 의존하여 다음 문제가 있습니다:

  • 키보드 사용자가 드롭다운에 접근할 수 없음
  • 터치 디바이스에서 hover 상태가 불안정함

향후 개선 시 focus-within을 추가하거나, Radix UI의 DropdownMenu 같은 접근성이 보장된 컴포넌트 사용을 권장합니다.

♻️ 간단한 개선 제안
-<div className="absolute top-full right-0 z-20 hidden w-[120px] pt-50 group-hover:block">
+<div className="absolute top-full right-0 z-20 hidden w-[120px] pt-50 group-hover:block group-focus-within:block">
src/hooks/use-scroll-to-home-content.ts (1)

61-75: ESLint react-hooks/exhaustive-deps 경고 처리 필요

deps 파라미터를 useEffect 의존성 배열로 직접 전달하면 ESLint의 react-hooks/exhaustive-deps 규칙 경고가 발생합니다. 이는 의도된 동작이므로 eslint-disable 주석을 추가하는 것이 좋습니다.

🔧 ESLint 경고 처리 제안
   useEffect(() => {
     if (options?.enabled === false) return;
     if (isFirst.current) {
       isFirst.current = false;

       return;
     }
     scrollToHomeContent();
     if (options?.stabilize) {
       stabilizeScrollAfterLayout(
         scrollToHomeContent,
         options?.stabilizeDurationMs ?? 800,
       );
     }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, deps);
src/api/client/axios.server.ts (1)

17-18: 인터셉터 등록 순서 확인 필요

attachApiLogger가 다른 인터셉터(onRequestServer) 등록 전에 호출되었습니다. 이 순서에서:

  • 요청: 로거 → 토큰 추가 순으로 실행
  • 응답: 에러 핸들러 → 로거 순으로 실행

토큰이 추가된 후의 요청을 로깅하려면 attachApiLogger를 다른 인터셉터 등록 후로 이동하는 것이 좋습니다.

♻️ 제안된 수정
 attachApiLogger(axiosServerInstance, 'server-json');
-
 const onRequestServer = async (config: InternalAxiosRequestConfig) => {

또는 로거를 마지막에 등록:

 axiosServerInstance.interceptors.request.use(onRequestServer);
 
 axiosServerInstance.interceptors.response.use(
   (config) => config,
   async (error) => {
     return Promise.reject(error);
   },
 );
+
+attachApiLogger(axiosServerInstance, 'server-json');
src/features/study/one-to-one/archive/model/use-update-archive-mutation.ts (1)

34-36: 타입 안전성 관련 참고 사항

{ ...item, ...request } 패턴은 UpdateArchiveRequest의 필드명이 ArchiveItem의 필드명과 일치한다고 가정합니다. 현재는 올바르게 작동하지만, 추후 API 스키마 변경 시 주의가 필요합니다.

src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-header.tsx (1)

11-12: Tailwind 클래스 정렬 적용 권장

Line 12의 className 순서가 Tailwind 정렬 규칙과 다릅니다. prettier-plugin-tailwindcss로 정렬을 한 번 적용해주세요.

✅ 정렬 예시
-        <Trophy className="text-text-warning h-8 w-8" fill="currentColor" />
+        <Trophy className="h-8 w-8 text-text-warning" fill="currentColor" />
src/features/study/one-to-one/archive/ui/archive-filters.tsx (1)

183-183: Tailwind 클래스 정렬 일괄 적용 필요

Line 183 등 다수의 className에서 정렬 규칙이 섞여 있습니다. prettier-plugin-tailwindcss로 파일 전체 정렬을 권장합니다.

src/components/voting/voting-tag-field.tsx (2)

32-36: 최대 태그 수 표기를 상수와 동기화해주세요.

라벨에 (최대 3개)가 하드코딩되어 있어 상수 변경 시 UI가 쉽게 불일치합니다. BALANCE_GAME_TAG_MAX_COUNT를 그대로 사용하는 편이 안전합니다.

🔧 제안 변경
-        <span className="font-designer-12r text-text-subtle ml-100">
-          (최대 3개)
-        </span>
+        <span className="font-designer-12r text-text-subtle ml-100">
+          (최대 {BALANCE_GAME_TAG_MAX_COUNT}개)
+        </span>

31-35: prettier-plugin-tailwindcss를 사용하여 Tailwind 클래스 자동 정렬을 적용해주세요.

ml-100 같은 spacing 클래스가 typography 클래스 뒤에 위치하고 있습니다. 프로젝트 코딩 가이드라인에 따라 prettier-plugin-tailwindcss의 자동 정렬 규칙으로 클래스 순서를 통일해주세요. 이렇게 하면 Tailwind의 CSS 생성 순서(base → components → utilities)에 맞게 자동으로 정렬됩니다.

src/features/study/one-to-one/schedule/ui/study-tutorial-controller.tsx (1)

6-10: src 내부 import는 @/ alias로 통일해주세요.

상대경로 대신 @/ alias를 사용하면 경로 안정성과 일관성이 좋아집니다.

🔧 제안 변경
-import StudyCard from './study-card';
-import {
-  STUDY_TUTORIAL_STEPS,
-  getStudyTutorialScenario,
-} from '../model/study-tutorial-steps';
+import StudyCard from '@/features/study/one-to-one/schedule/ui/study-card';
+import {
+  STUDY_TUTORIAL_STEPS,
+  getStudyTutorialScenario,
+} from '@/features/study/one-to-one/schedule/model/study-tutorial-steps';

As per coding guidelines, **/*.{ts,tsx,js,jsx}: Use path alias @/* to reference src/* directory as configured in tsconfig.json for import statements.

src/components/tutorial/index.ts (1)

1-2: 튜토리얼 모듈도 @/ alias 사용을 권장합니다.

상대경로 대신 alias로 통일하면 경로 변경에 강해집니다.

🔧 제안 변경
-export { default as TutorialOverlay } from './tutorial-overlay';
-export type { TutorialStep } from './tutorial-overlay';
+export { default as TutorialOverlay } from '@/components/tutorial/tutorial-overlay';
+export type { TutorialStep } from '@/components/tutorial/tutorial-overlay';

As per coding guidelines, **/*.{ts,tsx,js,jsx}: Use path alias @/* to reference src/* directory as configured in tsconfig.json for import statements.

src/components/ui/action-pill-button.tsx (2)

13-44: CVA를 사용하여 variant/size 클래스 관리를 일관되게 정리하세요.

프로젝트의 다른 UI 컴포넌트들(radio, toggle, pagination, input, chip, badge, button, tab-menu)은 모두 CVA를 사용 중이므로, 이 컴포넌트도 동일한 패턴을 따르면 타입 안정성과 확장성이 향상됩니다.

🔧 CVA 적용 예시
+import { cva } from 'class-variance-authority';
 import { cn } from '@/components/ui/(shadcn)/lib/utils';
 
-type ActionPillVariant = 'primary' | 'neutral' | 'ghost';
-type ActionPillSize = 'xs' | 'sm' | 'md';
+type ActionPillVariant = 'primary' | 'neutral' | 'ghost';
+type ActionPillSize = 'xs' | 'sm' | 'md';
 
-const VARIANT_CLASSES: Record<ActionPillVariant, string> = {
-  primary:
-    'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover',
-  neutral:
-    'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover',
-  ghost:
-    'bg-background-default text-text-subtle hover:bg-fill-neutral-subtle-hover',
-};
-
-const SIZE_CLASSES: Record<ActionPillSize, string> = {
-  xs: 'px-100 py-50 font-designer-11m',
-  sm: 'px-150 py-50 font-designer-12m',
-  md: 'px-200 py-100 font-designer-12m',
-};
+const actionPill = cva(
+  'rounded-100 flex items-center gap-50 transition-colors',
+  {
+    variants: {
+      variant: {
+        primary:
+          'bg-fill-brand-default-default text-text-inverse hover:bg-fill-brand-default-hover',
+        neutral:
+          'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover',
+        ghost:
+          'bg-background-default text-text-subtle hover:bg-fill-neutral-subtle-hover',
+      },
+      size: {
+        xs: 'px-100 py-50 font-designer-11m',
+        sm: 'px-150 py-50 font-designer-12m',
+        md: 'px-200 py-100 font-designer-12m',
+      },
+    },
+    defaultVariants: { variant: 'ghost', size: 'sm' },
+  },
+);
 
   return (
     <button
       type="button"
       className={cn(
-        'rounded-100 flex items-center gap-50 transition-colors',
-        VARIANT_CLASSES[variant],
-        SIZE_CLASSES[size],
+        actionPill({ variant, size }),
         className,
       )}

1-2: CVA를 사용하여 동적 클래스 관리를 리팩토링하세요.

현재 VARIANT_CLASSESSIZE_CLASSES 객체로 스타일을 관리하고 있습니다. 코딩 가이드라인에 따라 class-variance-authority(CVA)를 사용하여 variant와 size 조합을 더 명확하게 관리하는 것을 추천합니다.

참고: use client 지시어는 필요하지 않습니다. 이 컴포넌트는 이벤트 핸들러 props를 상위 클라이언트 컴포넌트(archive-tab-client.tsx)로부터 받으므로 서버 컴포넌트로 유지될 수 있습니다.

src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-mvp-section.tsx (1)

16-26: Tailwind 클래스 정렬을 플러그인 규칙으로 맞춰주세요.

Line 17, Line 25 등에서 className 순서가 Tailwind 정렬 규칙과 다릅니다. prettier-plugin-tailwindcss로 자동 정렬해주세요.

♻️ 제안되는 정렬 예시
-      <h3 className="font-display-headings6 text-text-strong flex items-center gap-100">
-        <Users className="text-text-information h-5 w-5" />
+      <h3 className="flex items-center gap-100 font-display-headings6 text-text-strong">
+        <Users className="h-5 w-5 text-text-information" />
       </h3>
...
-        <div className="bg-background-default border-border-subtle rounded-200 flex h-[400px] items-center justify-center border">
+        <div className="flex h-[400px] items-center justify-center rounded-200 border border-border-subtle bg-background-default">

As per coding guidelines: **/*.{tsx,ts,jsx,js}: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.

src/features/study/one-to-one/archive/api/get-archive-search-suggestions.ts (1)

7-13: 응답 타입 가드에서 배열 여부까지 검증해주세요.

Line 7-13은 키 존재만 확인해 titles/authors가 배열이 아니어도 통과합니다. 배열 체크로 안정성을 높이는 편이 안전합니다.

♻️ 제안 수정안
-const isArchiveSearchSuggestionResponse = (
-  value: unknown,
-): value is ArchiveSearchSuggestionResponse =>
-  !!value &&
-  typeof value === 'object' &&
-  'titles' in value &&
-  'authors' in value;
+const isArchiveSearchSuggestionResponse = (
+  value: unknown,
+): value is ArchiveSearchSuggestionResponse =>
+  !!value &&
+  typeof value === 'object' &&
+  Array.isArray((value as ArchiveSearchSuggestionResponse).titles) &&
+  Array.isArray((value as ArchiveSearchSuggestionResponse).authors);
src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx (1)

18-40: Tailwind 클래스 순서를 플러그인 규칙으로 정렬해주세요.

Line 19-21, Line 39 등 여러 className이 순서 규칙과 다릅니다. prettier-plugin-tailwindcss로 정렬해주세요.

♻️ 정렬 예시
-      className={cn(
-        'rounded-200 shadow-2 relative overflow-hidden border border-[`#FFEBA4`] bg-gradient-to-br from-[`#FFF8E7`] to-[`#FFF`] p-500',
-        className,
-      )}
+      className={cn(
+        'relative overflow-hidden rounded-200 border border-[`#FFEBA4`] bg-gradient-to-br from-[`#FFF8E7`] to-[`#FFF`] p-500 shadow-2',
+        className,
+      )}
...
-              className="border-border-subtle text-text-strong bg-background-default shadow-1 font-designer-24b flex h-600 w-600 items-center justify-center rounded-full border leading-none"
+              className="flex h-600 w-600 items-center justify-center rounded-full border border-border-subtle bg-background-default font-designer-24b leading-none text-text-strong shadow-1"
As per coding guidelines: `**/*.{tsx,ts,jsx,js}`: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.
src/features/study/one-to-one/archive/ui/archive-pagination.tsx (1)

28-30: Tailwind 클래스 정렬을 플러그인 규칙으로 맞춰주세요.

Line 28 className 순서가 정렬 규칙과 다릅니다. prettier-plugin-tailwindcss로 통일해주세요.

♻️ 정렬 예시
-      <span className="font-designer-15m text-text-subtle bg-background-default border-border-subtle flex h-[40px] items-center justify-center rounded-[9999px] border px-300">
+      <span className="flex h-[40px] items-center justify-center rounded-[9999px] border border-border-subtle bg-background-default px-300 font-designer-15m text-text-subtle">
As per coding guidelines: `**/*.{tsx,ts,jsx,js}`: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.
src/components/ui/filters/filter-pill-button.tsx (1)

24-31: 상태별 클래스는 CVA로 분리하는 게 유지보수에 유리합니다
isActive/disabled 조합이 늘어나면 조건식이 복잡해지므로, cva로 variant를 정의해 관리하는 편이 안전합니다.

♻️ 제안 수정안
-import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
+import { cva } from 'class-variance-authority';
+
+const filterPillButtonVariants = cva(
+  'rounded-100 px-300 py-150 font-designer-13b transition-all',
+  {
+    variants: {
+      isActive: {
+        true: 'bg-fill-brand-default-default text-text-inverse shadow-1',
+        false:
+          'border border-border-subtle bg-background-default text-text-subtle hover:border-border-brand hover:text-text-brand',
+      },
+      disabled: {
+        true: 'cursor-not-allowed opacity-50',
+      },
+    },
+    defaultVariants: {
+      isActive: false,
+      disabled: false,
+    },
+  },
+);
@@
-      className={cn(
-        'rounded-100 font-designer-13b px-300 py-150 transition-all',
-        isActive
-          ? 'bg-fill-brand-default-default text-text-inverse shadow-1'
-          : 'border-border-subtle bg-background-default text-text-subtle hover:border-border-brand hover:text-text-brand border',
-        disabled && 'cursor-not-allowed opacity-50',
-        className,
-      )}
+      className={cn(filterPillButtonVariants({ isActive, disabled }), className)}

As per coding guidelines, Use class composition utilities clsx, tailwind-merge, and class-variance-authority (CVA) for dynamic Tailwind class management.

src/features/study/one-to-one/hall-of-fame/ui/rank-badge.tsx (1)

11-28: SVG는 SVGR 컴포넌트로 import해 사용하세요
문자열 경로 대신 SVGR로 불러오면 스타일/테마 적용과 번들 최적화가 쉬워집니다. 아이콘 경로는 프로젝트 위치에 맞춰 조정해주세요.

♻️ 제안 수정안
-import Image from 'next/image';
+import GoldRankIcon from '@/assets/icons/gold-rank.svg';
+import SilverRankIcon from '@/assets/icons/silver-rank.svg';
+import BronzeRankIcon from '@/assets/icons/bronze-rank.svg';
@@
-  const iconPath =
-    rank === 1
-      ? '/icons/gold-rank.svg'
-      : rank === 2
-        ? '/icons/silver-rank.svg'
-        : '/icons/bronze-rank.svg';
+  const Icon =
+    rank === 1 ? GoldRankIcon : rank === 2 ? SilverRankIcon : BronzeRankIcon;
@@
-  return (
-    <div className="relative h-[48px] w-[36px] md:h-[60px] md:w-[45px]">
-      <Image src={iconPath} alt={`${rank}위`} fill className="object-contain" />
-    </div>
-  );
+  return (
+    <div className="relative h-[48px] w-[36px] md:h-[60px] md:w-[45px]">
+      <Icon className="h-full w-full" aria-label={`${rank}위`} />
+    </div>
+  );

As per coding guidelines, Import and use SVG files as React components via @svgr/webpack configured in next.config.ts.

src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-ranker-section.tsx (1)

5-8: src 경로는 @/ 별칭으로 통일해주세요
가이드라인상 src/* import는 @/ 경로를 사용해야 합니다. 동일 폴더라도 별칭으로 맞추면 일관성이 좋아집니다.

♻️ 제안 수정안
-import type { RankingType, RankerWithLabel } from './hall-of-fame-constants';
-import { TAB_CONFIG } from './hall-of-fame-constants';
-import RankerListItem from './ranker-list-item';
-import RankingTabButton from './ranking-tab-button';
+import type { RankingType, RankerWithLabel } from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants';
+import { TAB_CONFIG } from '@/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants';
+import RankerListItem from '@/features/study/one-to-one/hall-of-fame/ui/ranker-list-item';
+import RankingTabButton from '@/features/study/one-to-one/hall-of-fame/ui/ranking-tab-button';

As per coding guidelines, Use path alias @/* to reference src/* directory as configured in tsconfig.json for import statements.

src/components/card/voting-card.tsx (2)

23-41: 비활성 상태 클래스가 현재는 중복

기본 클래스에 이미 bg-background-default가 있어 !isActive 조건이 효과가 없습니다. 의도된 스타일이 없다면 조건을 제거하는 편이 낫습니다.

✏️ 수정 제안
-        !isActive && 'bg-background-default',

144-149: 불필요한 분기/주석 정리 권장

onClick 유무와 무관하게 동일 반환이라 분기와 주석이 의미가 없습니다. 단순 반환으로 정리하세요.

✏️ 수정 제안
-  // onClick이 있으면 Link 없이 렌더링, 없으면 Link로 감싸기
-  if (onClick) {
-    return cardContent;
-  }
-
-  return cardContent;
+  // 현재는 Link 래핑 없이 동일한 cardContent를 반환
+  return cardContent;
src/features/study/one-to-one/balance-game/ui/balance-game-filters-bar.tsx (1)

216-296: 검색 제안 드롭다운 조건부 렌더링 로직 개선 고려

현재 드롭다운 표시 조건이 두 곳(Lines 216-296, 297-308)에 분리되어 있고, flatSuggestions.length > 0 || isFetching 조건과 !isFetching && flatSuggestions.length === 0 조건이 중복된 검사를 수행합니다. 가독성과 유지보수를 위해 하나의 조건부 블록으로 통합하는 것을 권장합니다.

♻️ 통합 제안
-            {isOpen &&
-              (flatSuggestions.length > 0 || isFetching) &&
-              searchTerm.trim().length >= minQueryLength && (
-                <div className="absolute top-full left-0 z-30 mt-100 w-full">
-                  ...
-                </div>
-              )}
-            {isOpen &&
-              !isFetching &&
-              flatSuggestions.length === 0 &&
-              searchTerm.trim().length >= minQueryLength && (
-                <div className="absolute top-full left-0 z-30 mt-100 w-full">
-                  ...
-                </div>
-              )}
+            {isOpen && searchTerm.trim().length >= minQueryLength && (
+              <div className="absolute top-full left-0 z-30 mt-100 w-full">
+                <div className="bg-background-default border-border-subtle rounded-150 shadow-2 overflow-hidden border py-100">
+                  {isFetching ? (
+                    <div className="font-designer-13r text-text-subtlest px-200 py-150">
+                      검색 중...
+                    </div>
+                  ) : flatSuggestions.length === 0 ? (
+                    <div className="font-designer-13r text-text-subtlest px-200 py-150">
+                      검색 결과가 없습니다
+                    </div>
+                  ) : (
+                    <>
+                      {/* title and author sections */}
+                    </>
+                  )}
+                </div>
+              </div>
+            )}
src/features/study/one-to-one/hall-of-fame/ui/hall-of-fame-constants.ts (1)

39-57: addScoreLabel에서 TAB_CONFIG.unit 재사용 권장

addScoreLabel 함수에서 단위 문자열('회', '건', '℃')이 TAB_CONFIGunit 필드와 중복되어 있습니다. DRY 원칙에 따라 TAB_CONFIG를 활용하면 유지보수성이 향상됩니다.

♻️ 리팩토링 제안
 export const addScoreLabel = (
   ranker: Ranker,
   type: RankingType,
 ): RankerWithLabel => {
-  let scoreLabel = '';
-
-  if (type === 'ATTENDANCE') {
-    scoreLabel = `${ranker.score}회`;
-  } else if (type === 'STUDY_LOG') {
-    scoreLabel = `${ranker.score}건`;
-  } else {
-    scoreLabel = `${ranker.score}℃`;
-  }
+  const { unit } = TAB_CONFIG[type];
+  const scoreLabel = `${ranker.score}${unit}`;
 
   return {
     ...ranker,
     scoreLabel,
   };
 };
src/features/study/one-to-one/balance-game/ui/tag-autocomplete.tsx (1)

58-71: 클릭 외부 처리 로직 중복

이 컴포넌트의 click-outside 핸들링 로직(Lines 58-71)이 balance-game-filters-bar.tsx(Lines 79-92)와 동일합니다. 재사용 가능한 커스텀 훅(useClickOutside)으로 추출하면 코드 중복을 줄이고 유지보수성을 높일 수 있습니다.

♻️ 커스텀 훅 추출 제안
// src/hooks/use-click-outside.ts
import { useEffect, RefObject } from 'react';

export const useClickOutside = (
  ref: RefObject<HTMLElement>,
  isActive: boolean,
  onClickOutside: () => void
) => {
  useEffect(() => {
    if (!isActive) return;
    const handleClickOutside = (event: MouseEvent) => {
      if (!ref.current) return;
      if (!ref.current.contains(event.target as Node)) {
        onClickOutside();
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [isActive, onClickOutside, ref]);
};

Also applies to: 79-92

src/components/voting/voting-create-modal.tsx (1)

85-93: minLength 파라미터가 중복 사용되고 있습니다.

enabled 조건(line 90)과 minLength 옵션(line 91) 모두에서 BALANCE_GAME_TAG_MIN_QUERY_LEN을 사용하고 있습니다. useBalanceGameTagSuggestionsQuery 훅 내부에서 이미 minLength를 기반으로 enabled를 계산하므로, 외부에서 enabled를 직접 지정할 필요가 없을 수 있습니다.

♻️ 간소화 제안
  const { data: tagSuggestions = [], isFetching: isTagLoading } =
    useBalanceGameTagSuggestionsQuery(trimmedTagQuery, {
      limit: 10,
-     enabled: trimmedTagQuery.length >= BALANCE_GAME_TAG_MIN_QUERY_LEN,
      minLength: BALANCE_GAME_TAG_MIN_QUERY_LEN,
      sort: 'popular',
    });
src/features/study/one-to-one/balance-game/ui/community-tab-client.tsx (1)

273-280: key에서 index 사용이 불필요해 보입니다.

voting.id가 고유하다면 ${voting.id}-${index} 대신 voting.id만 사용해도 충분합니다. index를 추가하면 동일한 ID가 있는 경우 버그를 숨길 수 있고, React의 재조정 최적화에도 영향을 줄 수 있습니다.

♻️ 간소화 제안
-              {visibleVotings.map((voting, index) => (
+              {visibleVotings.map((voting) => (
                 <VotingCard
-                  key={`${voting.id}-${index}`}
+                  key={voting.id}
                   voting={voting}
src/components/tutorial/tutorial-overlay.tsx (2)

240-252: 타겟 요소를 찾지 못했을 때의 처리 개선 필요

missingCountRef가 20에 도달한 후 0으로 리셋되고 아무 동작도 하지 않습니다. 타겟 요소를 찾을 수 없는 경우, 다음 단계로 이동하거나 오버레이를 닫거나, 적어도 경고를 출력하는 것이 좋습니다.

♻️ 개선 제안
      if (missingCountRef.current <= 20) {
        const id = window.setTimeout(() => {
          setRecheckKey((prev) => prev + 1);
        }, 50);

        return () => window.clearTimeout(id);
      }
      missingCountRef.current = 0;
+     // 타겟을 찾지 못한 경우 다음 단계로 건너뛰거나 경고 출력
+     console.warn(`Tutorial target not found for step: ${step.id}`);
+     if (resolvedActiveIndex < steps.length - 1) {
+       setStepIndex(resolvedActiveIndex + 1);
+     } else {
+       requestClose();
+     }

      return;

442-472: 버튼 스타일을 공통 컴포넌트로 통일하는 것을 고려해 보세요

현재 인라인 스타일로 버튼이 정의되어 있습니다. 코드베이스에 ActionPillButton 같은 공통 버튼 컴포넌트가 있으니, 일관성을 위해 해당 컴포넌트를 사용하거나 Tailwind 클래스를 활용하는 것을 권장합니다.

src/features/study/one-to-one/archive/ui/archive-grid.tsx (1)

23-268: LibraryRowLibraryCard의 편집 로직 중복 고려

archive-list.tsxLibraryRow와 이 파일의 LibraryCard가 거의 동일한 인라인 편집 로직(상태 관리, handleSave, handleCancel, useEffect)을 가지고 있습니다. 향후 유지보수를 위해 공통 로직을 커스텀 훅으로 추출하는 것을 고려해 보세요.

♻️ 커스텀 훅 추출 예시
// useArchiveEditing.ts
export function useArchiveEditing(
  item: ArchiveItem,
  onUpdate: (id: number, request: UpdateArchiveRequest) => void,
) {
  const [isEditing, setIsEditing] = React.useState(false);
  const [title, setTitle] = React.useState(item.title);
  const [description, setDescription] = React.useState(item.description ?? '');
  const [nextPrivate, setNextPrivate] = React.useState(!!item.isPrivate);

  React.useEffect(() => {
    if (!isEditing) {
      setTitle(item.title);
      setDescription(item.description ?? '');
      setNextPrivate(!!item.isPrivate);
    }
  }, [isEditing, item.title, item.description, item.isPrivate]);

  const handleSave = (e: React.MouseEvent) => {
    e.stopPropagation();
    const request: UpdateArchiveRequest = {};
    // ... save logic
  };

  const handleCancel = (e: React.MouseEvent) => {
    e.stopPropagation();
    setIsEditing(false);
    setTitle(item.title);
    setDescription(item.description ?? '');
    setNextPrivate(!!item.isPrivate);
  };

  return {
    isEditing,
    setIsEditing,
    title,
    setTitle,
    description,
    setDescription,
    nextPrivate,
    setNextPrivate,
    handleSave,
    handleCancel,
  };
}
src/components/ui/modal-shell.tsx (1)

30-30: Tailwind 클래스 순서를 가이드라인에 맞춰 정렬해 주세요.
Modal.Header의 className 순서가 정렬 규칙과 다릅니다. prettier-plugin-tailwindcss 기준으로 정렬하면 규칙과 자동 정렬이 일치합니다.

✅ 정렬 예시
-          <Modal.Header className="border-border-default flex items-center justify-between border-b">
+          <Modal.Header className="flex items-center justify-between border-b border-border-default">
As per coding guidelines: `**/*.{tsx,ts,jsx,js}`: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.
src/features/study/interview/ui/study-ready-modal.tsx (1)

78-79: Footer 컨테이너의 Tailwind 클래스 정렬을 맞춰 주세요.
prettier-plugin-tailwindcss 기준으로 정렬해 두면 팀 규칙과 자동 정렬이 일치합니다.

✅ 정렬 예시
-        <div className="flex justify-end gap-100">
+        <div className="flex gap-100 justify-end">
As per coding guidelines: `**/*.{tsx,ts,jsx,js}`: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.
src/features/study/interview/ui/study-done-modal.tsx (1)

91-92: Footer 컨테이너의 Tailwind 클래스 정렬을 맞춰 주세요.
prettier-plugin-tailwindcss 기준 정렬로 통일하는 편이 좋습니다.

✅ 정렬 예시
-        <div className="flex justify-end gap-100">
+        <div className="flex gap-100 justify-end">
As per coding guidelines: `**/*.{tsx,ts,jsx,js}`: Organize Tailwind CSS classes using prettier-plugin-tailwindcss for automatic class ordering and sort them alphabetically within groups.

Comment on lines +44 to +81
instance.interceptors.request.use((config) => {
const method = (config.method || 'get').toUpperCase();
const url = normalizeUrl(config);
const params = stringifyParams(config.params);

console.log(
`[API ${label}] ${method} ${url}${params ? ` params=${params}` : ''}`,
);

return config;
});

instance.interceptors.response.use(
(response) => {
const method = (response.config.method || 'get').toUpperCase();
const url = normalizeUrl(response.config);
const data = stringifyData(response.data);

console.log(`[API ${label}] ${method} ${url} -> ${response.status}`);
if (data) {
console.log(`[API ${label}] response=${data}`);
}

return response;
},
(error: AxiosError) => {
const config = error.config;
const method = config?.method?.toUpperCase() || 'UNKNOWN';
const url = config ? normalizeUrl(config) : 'unknown';
const status = error.response?.status;
const data = stringifyData(error.response?.data);

console.log(
`[API ${label}] ${method} ${url} -> ERROR${status ? ` ${status}` : ''}`,
);
if (data) {
console.log(`[API ${label}] response=${data}`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

응답/파라미터 로그에 민감정보 노출 위험

비프로덕션이라도 팀 공용 로그에 PII/토큰이 기록될 수 있습니다. 본문/파라미터 로그는 명시적 플래그로 제한하거나 마스킹을 권장합니다.

✅ 로그 본문 opt-in 예시
-const shouldLog = process.env.NODE_ENV !== 'production';
+const shouldLog = process.env.NODE_ENV !== 'production';
+const shouldLogBody = process.env.NEXT_PUBLIC_API_LOG_BODY === 'true';

@@
-      if (data) {
+      if (shouldLogBody && data) {
         console.log(`[API ${label}] response=${data}`);
       }
@@
-      if (data) {
+      if (shouldLogBody && data) {
         console.log(`[API ${label}] response=${data}`);
       }
🤖 Prompt for AI Agents
In `@src/api/client/api-logger.ts` around lines 44 - 81, The request/response
logging in instance.interceptors.request.use and
instance.interceptors.response.use currently prints params/body via
stringifyParams and stringifyData which can expose PII/tokens; make logging of
params and response body opt-in via a configurable boolean (e.g.,
enableDetailedApiLogging or per-client option) and, when disabled, either omit
those fields or replace them using a masking function (e.g., maskSensitiveData)
before calling console.log; update usages around the Axios instance creation and
the label-aware logs so that logs still include method/url/status but only
include params/data when the opt-in flag is true or after masking.

Comment on lines +23 to +30
const getTodayDateString = () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');

return `${year}-${month}-${day}`;
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

서버-클라이언트 Hydration 불일치 위험

PR 제목이 "hydration 불일치 오류" 해결을 언급하고 있는데, getTodayDateString()이 서버와 클라이언트에서 각각 실행되면서 시간대 차이로 인해 다른 날짜를 반환할 수 있습니다.

min 속성에 사용되는 오늘 날짜를 클라이언트에서만 계산하도록 수정하는 것을 권장합니다.

🐛 Hydration 안전한 구현 제안
+'use client';
+
+import React, { useState, useEffect } from 'react';
 import { useFormContext } from 'react-hook-form';
 import { BaseInput } from '@/components/ui/input';
 import type { VotingCreateFormData } from '@/types/schemas/zod-schema';
 
 export default function VotingDeadlineField() {
   const { watch, setValue } = useFormContext<VotingCreateFormData>();
   const watchedEndsAt = watch('endsAt') || '';
+  const [todayString, setTodayString] = useState<string>('');
+
+  useEffect(() => {
+    const today = new Date();
+    const year = today.getFullYear();
+    const month = String(today.getMonth() + 1).padStart(2, '0');
+    const day = String(today.getDate()).padStart(2, '0');
+    setTodayString(`${year}-${month}-${day}`);
+  }, []);
 
   const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     // ...
   };
 
   const selectedDateOnly = watchedEndsAt ? watchedEndsAt.split('T')[0] : '';
 
-  const getTodayDateString = () => {
-    const today = new Date();
-    const year = today.getFullYear();
-    const month = String(today.getMonth() + 1).padStart(2, '0');
-    const day = String(today.getDate()).padStart(2, '0');
-
-    return `${year}-${month}-${day}`;
-  };
-
   return (
     <div className="flex flex-col gap-200">
       {/* ... */}
       <BaseInput
         type="date"
         value={selectedDateOnly}
         onChange={handleDateChange}
-        min={getTodayDateString()}
+        min={todayString}
       />
       {/* ... */}
     </div>
   );
 }

Also applies to: 41-41

🤖 Prompt for AI Agents
In `@src/components/voting/voting-deadline-field.tsx` around lines 23 - 30,
getTodayDateString() is being evaluated during SSR and on the client causing
timezone-based hydration mismatches; change the code so the date string is
computed only on the client (e.g., create a state like clientMinDate and set it
inside useEffect on mount) and use that state for the input's min prop (the
current usage around the input's min at line ~41). Keep getTodayDateString as a
helper but call it only inside the client-side effect (or initialize state with
undefined and set to getTodayDateString() in useEffect) so server-side render
omits/minimizes the min attribute and the client updates it after hydration.
Ensure references to getTodayDateString and the input's min prop are updated
accordingly.

Comment on lines +22 to +36
<PaginationCircleButton
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-5 w-5" />
</PaginationCircleButton>
<span className="font-designer-15m text-text-subtle bg-background-default border-border-subtle flex h-[40px] items-center justify-center rounded-[9999px] border px-300">
{currentPage} / {totalPages}
</span>
<PaginationCircleButton
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-5 w-5" />
</PaginationCircleButton>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/features/study/one-to-one/archive/ui/archive-pagination.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 1595


🏁 Script executed:

fd -e tsx -e ts -name "*[Pp]agination*" | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 312


🏁 Script executed:

rg "PaginationCircleButton" -g "*.tsx" -g "*.ts" -A5 -B2

Repository: code-zero-to-one/study-platform-client

Length of output: 7522


🏁 Script executed:

cat src/features/study/one-to-one/ui/pagination-circle-button.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 829


🏁 Script executed:

rg "aria-label" src/features/study/one-to-one/ -A2 -B2

Repository: code-zero-to-one/study-platform-client

Length of output: 695


🏁 Script executed:

rg "aria-label" -g "*.tsx" -g "*.ts" | grep -E "(button|Icon|Chevron)" -i | head -10

Repository: code-zero-to-one/study-platform-client

Length of output: 64


🏁 Script executed:

rg "ChevronLeft|ChevronRight" -g "*.tsx" -B5 -A2 | grep -E "(aria-label|children)" -A2 -B2

Repository: code-zero-to-one/study-platform-client

Length of output: 455


아이콘-only 버튼에 접근성 레이블이 필요합니다.

Lines 22-36의 좌/우 버튼은 텍스트가 없어 스크린리더 사용자가 의미를 알 수 없습니다. PaginationCircleButton 컴포넌트를 aria-label prop을 지원하도록 확장하고, 각 버튼에 다음과 같이 추가해주세요:

  • 좌측 버튼: aria-label="이전 페이지"
  • 우측 버튼: aria-label="다음 페이지"

PaginationCircleButtonProps 인터페이스에 aria-label?: string 을 추가하고, 버튼 요소에 해당 prop을 전달하도록 수정이 필요합니다.

🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/archive/ui/archive-pagination.tsx` around lines
22 - 36, Add accessible labels to the icon-only pagination buttons by extending
PaginationCircleButtonProps to include an optional aria-label?: string, updating
the PaginationCircleButton component to accept and pass that prop through onto
the underlying button element (e.g., <button aria-label={props["aria-label"]}
...>), and then set aria-label="이전 페이지" on the left instance and aria-label="다음
페이지" on the right instance where PaginationCircleButton is used in
archive-pagination.tsx; ensure the prop typing and JSX spread/explicit attribute
are updated so TypeScript and JSX pass the label to the DOM.

Comment on lines +59 to +76
const archiveParams: GetArchiveParams = {
page: currentPage - 1,
size: ITEMS_PER_PAGE,
size: itemsPerPage,
sort: librarySort,
search: debouncedSearchTerm || undefined,
bookmarkedOnly: showBookmarkedOnly || undefined,
authorOnly: showMyOnly || undefined,
authorId: undefined,
};

const shouldUseInitialData =
archiveParams.page === initialParams.page &&
archiveParams.size === initialParams.size &&
archiveParams.sort === initialParams.sort &&
archiveParams.search === initialParams.search &&
archiveParams.bookmarkedOnly === initialParams.bookmarkedOnly;
archiveParams.bookmarkedOnly === initialParams.bookmarkedOnly &&
archiveParams.authorOnly === initialParams.authorOnly &&
archiveParams.authorId === initialParams.authorId;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check archive API params and authorId usage

# 1. Check archive-related files for authorId and authorOnly usage
echo "=== Checking archive-related files for authorId/authorOnly usage ==="
rg -nP 'authorId|authorOnly' -C3 src/features/study/one-to-one/archive/

# 2. Check useAuth hook for memberId
echo -e "\n=== Checking useAuth hook for memberId ==="
rg -nP 'memberId' src/hooks/common/use-auth.ts

# 3. Find GetArchiveParams interface definition
echo -e "\n=== Finding GetArchiveParams interface ==="
rg -nP 'GetArchiveParams|interface.*Archive.*Params' --type=ts -A5

Repository: code-zero-to-one/study-platform-client

Length of output: 10952


🏁 Script executed:

#!/bin/bash
# Additional investigation on archive functionality

# Check the file mentioned in the review
echo "=== Checking archive-tab-client.tsx ==="
cat -n src/features/study/one-to-one/archive/ui/archive-tab-client.tsx | sed -n '50,85p'

# Check API service/hook files for archive
echo -e "\n=== Finding archive API related files ==="
fd -t f 'archive' src/features/study/one-to-one/archive/ | grep -E '\.(ts|tsx)$'

Repository: code-zero-to-one/study-platform-client

Length of output: 2714


🏁 Script executed:

#!/bin/bash
# Get the full GetArchiveParams interface definition
echo "=== GetArchiveParams interface from types/archive.ts ==="
sed -n '30,50p' src/types/archive.ts

# Check if useAuth is imported/used in archive-tab-client.tsx
echo -e "\n=== Check archive-tab-client.tsx imports and hooks ==="
head -30 src/features/study/one-to-one/archive/ui/archive-tab-client.tsx

# Check the beginning of the component to see what hooks are being used
echo -e "\n=== Check hooks used in component ==="
sed -n '32,50p' src/features/study/one-to-one/archive/ui/archive-tab-client.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 2394


🏁 Script executed:

#!/bin/bash
# Check types/archive.ts for authorId and related fields
echo "=== Full archive types ==="
cat -n src/types/archive.ts | head -60

Repository: code-zero-to-one/study-platform-client

Length of output: 1616


🏁 Script executed:

#!/bin/bash
# Search for similar useAuth or user ID filtering patterns
echo "=== Check for useAuth usage in similar filtering/component files ==="
rg -nP 'useAuth|memberId.*query|authorId.*mutation' src/features/study/ --type=ts --type=tsx | grep -E '(filter|query|params)' | head -20

# Check if there are other archive-related filters with user ID
echo -e "\n=== Check archive API endpoint documentation or related files ==="
fd -t f 'archive' src/ | xargs rg -l 'authorId' --type=ts --type=tsx

# Look for server-side archive handler to understand backend expectations
echo -e "\n=== Check archive server-side implementation ==="
cat -n src/features/study/one-to-one/archive/api/get-archive.server.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 791


🏁 Script executed:

#!/bin/bash
# Check how other authenticated filters work
echo "=== Check useArchiveFilters implementation ==="
cat -n src/features/study/one-to-one/archive/ui/use-archive-filters.ts

# Check archive-filters component to understand showMyOnly
echo -e "\n=== Check archive-filters component ==="
grep -n 'showMyOnly\|authorOnly' -A3 -B3 src/features/study/one-to-one/archive/ui/archive-filters.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 2932


showMyOnly가 활성화될 때 현재 사용자의 authorId를 함께 전달하세요.

showMyOnlytrue일 때 authorOnly: true로 설정되지만, authorId는 항상 undefined로 유지되고 있습니다. 현재 컴포넌트는 useAuth 훅을 사용하지 않아 사용자 ID를 가져올 수 없는 상태입니다. 백엔드가 인증된 사용자의 ID를 자동으로 추론하지 않는다면 "내 아카이브만 보기" 필터가 제대로 작동하지 않을 수 있습니다.

useAuth 훅을 가져와 memberId를 얻은 후, showMyOnlytrue일 때 archiveParams.authorId에 이를 설정하는 것을 권장합니다.

🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/archive/ui/archive-tab-client.tsx` around lines
59 - 76, The archiveParams currently sets authorId to undefined even when
showMyOnly is true; import and call the useAuth hook to get memberId, and set
archiveParams.authorId = memberId when showMyOnly is true so the backend
receives the current user ID; update any related equality check
(shouldUseInitialData) that compares archiveParams.authorId to
initialParams.authorId to account for the populated memberId; ensure you
reference archiveParams, showMyOnly, authorId, useAuth, memberId and
shouldUseInitialData when making the changes.

Comment on lines +3 to +58
import { useState } from 'react';

type StatusFilter = 'active' | 'closed' | 'all';
type SortMode = 'latest' | 'popular';

interface BalanceGameFiltersOptions {
onChange?: () => void;
}

export const useBalanceGameFilters = (options?: BalanceGameFiltersOptions) => {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
const [sortMode, setSortMode] = useState<SortMode>('latest');
const [selectedTags, setSelectedTags] = useState<string[]>([]);

const triggerChange = () => {
options?.onChange?.();
};

const setStatus = (next: StatusFilter) => {
setStatusFilter((prev) => {
if (prev === next) return prev;
triggerChange();

return next;
});
};

const setSort = (next: SortMode) => {
setSortMode((prev) => {
if (prev === next) return prev;
triggerChange();

return next;
});
};

const addTag = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setSelectedTags((prev) => {
if (prev.includes(trimmed)) {
return prev;
}
triggerChange();

return [...prev, trimmed];
});
};

const removeTag = (tag: string) => {
setSelectedTags((prev) => {
if (!prev.includes(tag)) return prev;
triggerChange();

return prev.filter((item) => item !== tag);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 2056


setState 업데이트 내부에서 onChange를 호출하면 stale state를 참조할 수 있습니다

현재 onChange가 setState updater 내부에서 호출되어, 상태가 실제로 업데이트되기 전에 실행됩니다. 이는 React의 상태 관리 패턴 위반이며, onChange 콜백이 컴포넌트 상태를 읽으려 할 경우 이전 값을 참조하게 되어 API 호출이 틀린 필터 값으로 나갈 수 있습니다.

상태 변경 후 useEffect에서 onChange를 호출하도록 수정하세요:

🛠️ 제안 수정안
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
@@
-  const triggerChange = () => {
-    options?.onChange?.();
-  };
+  const didMountRef = useRef(false);
+
+  useEffect(() => {
+    if (!didMountRef.current) {
+      didMountRef.current = true;
+      return;
+    }
+    options?.onChange?.();
+  }, [statusFilter, sortMode, selectedTags, options?.onChange]);
@@
-      triggerChange();
-
       return next;
@@
-      triggerChange();
-
       return next;
@@
-      triggerChange();
-
       return [...prev, trimmed];
@@
-      triggerChange();
-
       return prev.filter((item) => item !== tag);
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/balance-game/ui/use-balance-game-filters.ts`
around lines 3 - 58, The current implementation calls triggerChange
(options?.onChange) inside setState updaters (in setStatus, setSort, addTag,
removeTag), causing onChange to run before state updates commit; move the
onChange invocation into a useEffect that watches [statusFilter, sortMode,
selectedTags] so onChange runs after state changes. Concretely: stop calling
triggerChange from inside the updater functions in useBalanceGameFilters (remove
triggerChange calls from setStatus, setSort, addTag, removeTag), keep or remove
the triggerChange helper as desired, and add a useEffect that calls
options?.onChange() whenever statusFilter, sortMode, or selectedTags change
(ensuring onChange is not called unnecessarily when values don't actually
change).

Comment on lines +49 to +53
<span className="bg-fill-warning-subtle-default text-text-warning font-designer-12b border-border-warning-subtle rounded-[9999px] border px-150 py-50">
1월 4주차 MVP 팀
</span>
<h3 className="font-display-headings5 text-text-strong">
최고의 스터디 메이트
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

MVP 주차 문구가 하드코딩되어 잘못 표시됩니다.

Line 49-51 고정 문자열은 다른 주차 데이터에서도 동일하게 노출됩니다. team.weekDate 또는 weekStartDate/weekEndDate 기반으로 동적으로 렌더링해주세요(포맷터가 있다면 사용 권장).

🐛 최소 수정 예시
-            1월 4주차 MVP 팀
+            {team.weekDate}
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/hall-of-fame/ui/mvp-team-card.tsx` around lines
49 - 53, Replace the hardcoded "1월 4주차 MVP 팀" span in mvp-team-card.tsx with a
dynamic string built from the team's date fields (e.g., use team.weekDate or
weekStartDate/weekEndDate) and format it with the project's date formatter (or
Intl.DateTimeFormat) before rendering; update the JSX inside the span in the
MVPTeamCard component to compute the week label (e.g., format weekStart/weekEnd
or parse team.weekDate and produce "M월 N주차") and render that value instead of
the fixed text.

Comment on lines +8 to +28
export const mapHistoryItem = (data: StudyHistoryContent): StudyHistoryItem => {
const dateObj = new Date(data.scheduledAt);
const dateStr = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')}`;
const dayName = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()];
const partner = data.partner
? {
id: data.partner.memberId,
name: data.partner.nickname,
profileImage: data.partner.profileImageUrl,
}
: null;

return {
id: data.studyId,
date: `${dateStr} (${dayName})`,
subject: data.title,
role: data.participation.role,
attendance:
data.participation.attendance === 'PRESENT' ? 'ATTENDED' : 'NOT_STARTED',
link: data.studyLink,
status: data.status === 'COMPLETE' ? 'COMPLETED' : 'IN_PROGRESS',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

날짜/요일 계산은 시간대 고정 포맷으로 처리해야 안전합니다.

new Date() + getFullYear/getDay는 클라이언트 로컬 시간대 기준이라 scheduledAt이 UTC/서버 시간대일 경우 날짜·요일이 하루씩 밀릴 수 있습니다. 특히 “시간 문제 해결” 목적이라면 Intl.DateTimeFormat로 고정 시간대(예: Asia/Seoul) 포맷을 쓰는 게 안전합니다.

🕒 제안 수정
-  const dateObj = new Date(data.scheduledAt);
-  const dateStr = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')}`;
-  const dayName = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()];
+  const dateObj = new Date(data.scheduledAt);
+  const dateParts = new Intl.DateTimeFormat('ko-KR', {
+    timeZone: 'Asia/Seoul',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  }).formatToParts(dateObj);
+  const get = (t: string) =>
+    dateParts.find((p) => p.type === t)?.value ?? '';
+  const dateStr = `${get('year')}.${get('month')}.${get('day')}`;
+  const dayName = new Intl.DateTimeFormat('ko-KR', {
+    timeZone: 'Asia/Seoul',
+    weekday: 'short',
+  }).format(dateObj);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const mapHistoryItem = (data: StudyHistoryContent): StudyHistoryItem => {
const dateObj = new Date(data.scheduledAt);
const dateStr = `${dateObj.getFullYear()}.${String(dateObj.getMonth() + 1).padStart(2, '0')}.${String(dateObj.getDate()).padStart(2, '0')}`;
const dayName = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()];
const partner = data.partner
? {
id: data.partner.memberId,
name: data.partner.nickname,
profileImage: data.partner.profileImageUrl,
}
: null;
return {
id: data.studyId,
date: `${dateStr} (${dayName})`,
subject: data.title,
role: data.participation.role,
attendance:
data.participation.attendance === 'PRESENT' ? 'ATTENDED' : 'NOT_STARTED',
link: data.studyLink,
status: data.status === 'COMPLETE' ? 'COMPLETED' : 'IN_PROGRESS',
export const mapHistoryItem = (data: StudyHistoryContent): StudyHistoryItem => {
const dateObj = new Date(data.scheduledAt);
const dateParts = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(dateObj);
const get = (t: string) =>
dateParts.find((p) => p.type === t)?.value ?? '';
const dateStr = `${get('year')}.${get('month')}.${get('day')}`;
const dayName = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
weekday: 'short',
}).format(dateObj);
const partner = data.partner
? {
id: data.partner.memberId,
name: data.partner.nickname,
profileImage: data.partner.profileImageUrl,
}
: null;
return {
id: data.studyId,
date: `${dateStr} (${dayName})`,
subject: data.title,
role: data.participation.role,
attendance:
data.participation.attendance === 'PRESENT' ? 'ATTENDED' : 'NOT_STARTED',
link: data.studyLink,
status: data.status === 'COMPLETE' ? 'COMPLETED' : 'IN_PROGRESS',
🤖 Prompt for AI Agents
In `@src/features/study/one-to-one/history/ui/study-history-utils.ts` around lines
8 - 28, mapHistoryItem uses new Date() and getFullYear/getDay which rely on
client local timezone and can shift scheduledAt dates; change it to use
Intl.DateTimeFormat with a fixed timeZone (e.g., 'Asia/Seoul') to derive the
year, month, day and weekday reliably. Inside mapHistoryItem replace
creation/usage of dateObj and manual getFullYear/getMonth/getDate/getDay with
Intl.DateTimeFormat('ko-KR', {timeZone: 'Asia/Seoul', year:'numeric',
month:'2-digit', day:'2-digit', weekday:'short'}) or formatToParts to extract
parts, then construct date string and weekday from those parts so date/`date`
and `date`-related values (and any weekday mapping) are computed in the Seoul
timezone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant